{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Getting started with MovingPandas\n",
    "\n",
    "<img align=\"right\" src=\"https://anitagraser.github.io/movingpandas/assets/img/movingpandas.png\">\n",
    "\n",
    "[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/anitagraser/movingpandas/master?filepath=tutorials/1-getting-started.ipynb)\n",
    "\n",
    "**<p style=\"color:#e31883\">This notebook demonstrates the current development version of MovingPandas.</p>**\n",
    "\n",
    "For tutorials using the latest release visit https://github.com/anitagraser/movingpandas-examples.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import urllib\n",
    "import os\n",
    "import pandas as pd\n",
    "import geopandas as gpd\n",
    "from geopandas import GeoDataFrame, read_file\n",
    "from shapely.geometry import Point, LineString, Polygon\n",
    "from fiona.crs import from_epsg\n",
    "from datetime import datetime, timedelta\n",
    "from matplotlib import pyplot as plt\n",
    "\n",
    "import sys\n",
    "sys.path.append(\"..\")\n",
    "import movingpandas as mpd\n",
    "mpd.show_versions()\n",
    "\n",
    "import warnings\n",
    "#warnings.simplefilter(\"ignore\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Creating a trajectory from scratch\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df = pd.DataFrame([\n",
    "  {'geometry':Point(0,0), 't':datetime(2018,1,1,12,0,0)},\n",
    "  {'geometry':Point(6,0), 't':datetime(2018,1,1,12,6,0)},\n",
    "  {'geometry':Point(6,6), 't':datetime(2018,1,1,12,10,0)},\n",
    "  {'geometry':Point(9,9), 't':datetime(2018,1,1,12,15,0)}\n",
    "]).set_index('t')\n",
    "geo_df = GeoDataFrame(df, crs=31256)\n",
    "toy_traj = mpd.Trajectory(geo_df, 1)\n",
    "toy_traj.df"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.to_line_gdf()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.to_traj_gdf(wkt=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can access **key information** about our trajectory by looking at the print output:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "print(toy_traj)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can also access the trajectories GeoDataFrame:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.df"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Visualizing trajectories\n",
    "\n",
    "To **visualize the trajectory**, we can turn it into a linestring.\n",
    "\n",
    "(The notebook environment automatically plots Shapely geometry objects like the LineString returned by to_linestring().)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.to_linestring()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can **compute the speed** of movement along the trajectory (between consecutive points). The values are in meters per second:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.add_speed(overwrite=True)\n",
    "toy_traj.df"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can also visualize the speed values:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.plot(column=\"speed\", linewidth=5, capstyle='round', legend=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In contrast to the earlier example where we visualized the whole trajectory as one linestring, the trajectory plot() function draws each line segment individually and thus each can have a different color."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Analyzing trajectories"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Extracting a moving object's position was at a certain time\n",
    "\n",
    "For example, let's have a look at the get_position_at() function:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.get_position_at(datetime(2018,1,1,12,6,0), method=\"nearest\")    "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "To see its coordinates, we can look at the print output:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(toy_traj.get_position_at(datetime(2018,1,1,12,6,0), method=\"nearest\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The method parameter describes what the function should do if there is no entry in the trajectory GeoDataFrame for the specified timestamp. \n",
    "\n",
    "For example, there is no entry at 2018-01-01 12:07:00"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "toy_traj.df"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(toy_traj.get_position_at(datetime(2018,1,1,12,7,0), method=\"nearest\"))\n",
    "print(toy_traj.get_position_at(datetime(2018,1,1,12,7,0), method=\"interpolated\"))\n",
    "print(toy_traj.get_position_at(datetime(2018,1,1,12,7,0), method=\"ffill\")) # from the previous row\n",
    "print(toy_traj.get_position_at(datetime(2018,1,1,12,7,0), method=\"bfill\")) # from the following row"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Extracting trajectory segments based on time or geometry (i.e. clipping)\n",
    "\n",
    "First, let's extract the trajectory segment for a certain time period:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "segment = toy_traj.get_segment_between(datetime(2018,1,1,12,6,0),datetime(2018,1,1,12,12,0))\n",
    "print(segment)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, let's extract the trajectory segment that intersects with a given polygon:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "xmin, xmax, ymin, ymax = 2, 8, -10, 5\n",
    "polygon = Polygon([(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin), (xmin, ymin)])\n",
    "polygon"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "intersections = toy_traj.clip(polygon)\n",
    "intersections"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "intersections.trajectories[0].plot(linewidth=5, capstyle='round')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Beyond toy trajectories: Loading trajectory data from GeoPackage\n",
    "\n",
    "The MovingPandas repository contains a demo GeoPackage file that can be loaded as follows:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%time\n",
    "gdf = read_file('data/demodata_geolife.gpkg')\n",
    "print(\"Finished reading {} rows\".format(len(df)))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "After reading the trajectory point data from file, we want to construct the trajectories."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Creating trajectories with TrajectoryCollection\n",
    "\n",
    "TrajectoryCollection is a convenience class that takes care of creating trajectories from a GeoDataFrame:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "traj_collection = mpd.TrajectoryCollection(gdf, 'trajectory_id', t='t')\n",
    "print(traj_collection)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "traj_collection.plot(column='trajectory_id', legend=True, figsize=(9,5))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Converting TrajectoryCollections back to GeoDataFrames\n",
    "\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "traj_collection.to_point_gdf()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "traj_collection.to_line_gdf()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "traj_collection.to_traj_gdf(wkt=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "These GeoDataFrames can be exported to different file formats using GeoPandas, as documented in https://geopandas.org/docs/user_guide/io.html"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "traj_collection.to_traj_gdf(wkt=True).to_file(\"temp.gpkg\", layer='trajectories', driver=\"GPKG\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "read_file('temp.gpkg').plot()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Visualizing real-world trajectories"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "my_traj = traj_collection.trajectories[1]\n",
    "print(my_traj)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "plot_defaults = {'linewidth':5, 'capstyle':'round', 'figsize':(9,3), 'legend':True}\n",
    "my_traj.plot(column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "To visualize trajectories in their geographical context, we can also create interactive plots with basemaps:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "hvplot_defaults = {'tiles':'CartoLight', 'frame_height':400, 'frame_width':700, 'cmap':'Viridis', 'colorbar':True}\n",
    "my_hvplot = my_traj.hvplot(c='speed', line_width=7.0, clim=(0,20), **hvplot_defaults)\n",
    "my_hvplot"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "And even put other layers on top:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "my_hvplot * gpd.GeoDataFrame([my_traj.get_row_at(datetime(2009,6,29,8,0,0))]).hvplot(geo=True, size=200, color='red')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Trajectory manipulation and handling\n",
    "\n",
    "### Finding intersections with a Shapely polygon\n",
    "\n",
    "The clip function can be used to extract trajectory segments that are located within an area of interest polygon.\n",
    "\n",
    "This is how to use clip on a list of Trajectory objects:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "xmin, xmax, ymin, ymax = 116.3685035,116.3702945,39.904675,39.907728\n",
    "polygon = Polygon([(xmin,ymin), (xmin,ymax), (xmax,ymax), (xmax,ymin), (xmin,ymin)])\n",
    "\n",
    "intersections = []\n",
    "for traj in traj_collection:\n",
    "    for intersection in traj.clip(polygon):\n",
    "        intersections.append(intersection)\n",
    "print(\"Found {} intersections\".format(len(intersections)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "intersections[2].plot(linewidth=5.0, capstyle='round')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Alternatively, using **TrajectoryCollection**:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "clipped = traj_collection.clip(polygon)\n",
    "clipped.trajectories[2].plot(linewidth=5.0, capstyle='round')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Splitting trajectories\n",
    "\n",
    "Gaps are quite common in trajectories. For example, GPS tracks may contain gaps if moving objects enter tunnels where GPS reception is lost. In other use cases, moving objects may leave the observation area for longer time before returning and continuing their recorded track.\n",
    "\n",
    "Depending on the use case, we therefore might want to split trajectories at observation gaps that exceed a certain minimum duration:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "my_traj = traj_collection.trajectories[1]\n",
    "print(my_traj)\n",
    "my_traj.plot(column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "split = mpd.ObservationGapSplitter(my_traj).split(gap=timedelta(minutes=5))\n",
    "for traj in split:\n",
    "    print(traj)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(nrows=1, ncols=len(split), figsize=(19,4))\n",
    "for i, traj in enumerate(split):\n",
    "    traj.plot(ax=axes[i], column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "split = mpd.StopSplitter(my_traj).split(min_duration=timedelta(minutes=1), max_diameter=30, min_length=500)\n",
    "for traj in split:\n",
    "    print(traj)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(nrows=1, ncols=len(split), figsize=(19,4))\n",
    "for i, traj in enumerate(split):\n",
    "    traj.plot(ax=axes[i], column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Alternatively, using the whole **TrajectoryCollection**:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "split = mpd.ObservationGapSplitter(traj_collection).split(gap=timedelta(minutes=15))\n",
    "print(split)\n",
    "fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(16,4))\n",
    "traj_collection.plot(ax=axes[0], column='trajectory_id', **plot_defaults)\n",
    "axes[0].set_title('Original Trajectories')\n",
    "split.plot(ax=axes[1], column='trajectory_id', **plot_defaults)\n",
    "axes[1].set_title('Split Trajectories')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Generalizing trajectories\n",
    "\n",
    "To reduce the size of trajectory objects, we can generalize them, for example, using the Douglas-Peucker algorithm:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "original_traj = traj_collection.trajectories[1]\n",
    "print(original_traj)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "original_traj.plot(column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Try different tolerance settings and observe the results in line geometry and therefore also length:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Spatial generalizers"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dp_generalized = mpd.DouglasPeuckerGeneralizer(original_traj).generalize(tolerance=0.001)\n",
    "dp_generalized.plot(column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('Original length: %s'%(original_traj.get_length()))\n",
    "print('Generalized length: %s'%(dp_generalized.get_length()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "An alternative generalization method is to down-sample the trajectory to ensure a certain time delta between records:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Temporal generalizers"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "time_generalized = mpd.MinTimeDeltaGeneralizer(original_traj).generalize(tolerance=timedelta(minutes=1))\n",
    "time_generalized.plot(column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "time_generalized.to_point_gdf().head(10)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "original_traj.to_point_gdf().head(10)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### Spatiotemporal generalizers"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "tdtr_generalized = mpd.TopDownTimeRatioGeneralizer(original_traj).generalize(tolerance=0.001)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's compare this to the basic Douglas-Peucker result:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(19,4))\n",
    "tdtr_generalized.plot(ax=axes[0], column='speed', vmax=20, **plot_defaults)\n",
    "dp_generalized.plot(ax=axes[1], column='speed', vmax=20, **plot_defaults)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Smoothing trajectories\n",
    "\n",
    "We use the `KalmanSmootherCV` trajectory smoother to smooth all trajectories based on the assumption of a **nearly-constant velocity (CV) model**. The `process_noise_std` and `measurement_noise_std` parameters can be used to tune the smoother:\n",
    "\n",
    "- `process_noise_std` governs the uncertainty associated with the adherence of the new (smooth) trajectories to the CV model assumption; higher values relax the assumption, therefore leading to less-smooth trajectories, and vice-versa. \n",
    "- `measurement_noise_std` controls the assumed error in the original trajectories; higher values dictate that the original trajectories are expected to be noisier (and therefore, less reliable), thus leading to smoother trajectories, and vice-versa. \n",
    "\n",
    "Try tuning these parameters and observe the resulting trajectories:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "smooth = mpd.trajectory_smoother.KalmanSmootherCV(split).smooth(process_noise_std=0.1, measurement_noise_std=10)\n",
    "print(smooth)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's visually compare the smooth and original (split) trajectories."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "hvplot_defaults = {'tiles':'CartoLight', 'frame_height':320, 'frame_width':320, 'cmap':'Viridis', 'colorbar':True}\n",
    "kwargs = {**hvplot_defaults, 'line_width':4}\n",
    "(split.hvplot(title='Original Trajectories', **kwargs) + \n",
    " smooth.hvplot(title='Smooth Trajectories', **kwargs))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "And finally, let's compare the calculated speeds:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "kwargs = {**hvplot_defaults, 'c':'speed', 'line_width':7, 'clim':(0,20)}\n",
    "(split.trajectories[2].hvplot(title='Original Trajectories', **kwargs) + \n",
    " smooth.trajectories[2].hvplot(title='Smooth Trajectories', **kwargs))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "As expected, the smooth trajectories are significantly less rugged (i.e. noisy) and exhibit smoother velocity transitions."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Continue exploring MovingPandas\n",
    "\n",
    "1. [Getting started](1-getting-started.ipynb)\n",
    "1. [Handling trajectory data files (reading & writing)](2-reading-data-from-files.ipynb)\n",
    "1. [TrajectoryCollection aggregation (flow maps)](3-generalization-and-aggregation.ipynb)\n",
    "1. [Stop detection](4-stop-detection.ipynb)\n",
    "1. [Working with local coordinates](5-local-coordinates.ipynb)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
